Skip to content

Add liquidation-guard: Kamino Lend health guard with unsigned rescue transactions - #104

Draft
Ansh-699 wants to merge 5 commits into
zeroclaw-labs:mainfrom
Ansh-699:submission/liquidation-guard
Draft

Add liquidation-guard: Kamino Lend health guard with unsigned rescue transactions#104
Ansh-699 wants to merge 5 commits into
zeroclaw-labs:mainfrom
Ansh-699:submission/liquidation-guard

Conversation

@Ansh-699

@Ansh-699 Ansh-699 commented Jul 20, 2026

Copy link
Copy Markdown

Track B: DeFi — custody tier T1. Builds unsigned transactions only: no key material ever enters the plugin, sendTransaction appears nowhere in src/, and every transaction ships with one zeroed signature slot for the user's own wallet to inspect and sign.

What this adds

plugins/liquidation-guard — a Solana-native ZeroClaw plugin that watches Kamino Lend obligations and keeps agents' wallets out of the liquidation queue. One tool, kamino_guard, four actions:

  • check — health assessment for one obligation: tiered warning (OK/WATCH/WARN/CRITICAL), liquidation-price forecast in both directions (collateral-fall and debt-rise, each denominated in its own asset's oracle price; for a pinned LST mint set the collateral-drop line alone is quoted at the underlying SOL level via stake rate, with its threshold and its quoted spot converted together), interest-drift attribution with borrow APY/utilization, and ranked remedies that restore to the WATCH boundary.
  • portfolio — the same across every obligation of a wallet.
  • rescue — builds a base64 unsigned repay_obligation_liquidity_v2 transaction (refresh_reserve×N repay-last → refresh_obligation → repay, 13 fixed accounts), capped by min(computed Δ, requested, max_repay_ui, wallet balance) with a truthful cap label.
  • deposit — the other inbound remedy: a base64 unsigned deposit_reserve_liquidity_and_obligation_collateral_v2 transaction into the obligation's dominant collateral reserve, same fail-closed gate shape (max_deposit_ui absent = disabled) and the same cap/label discipline.

Both tx builders compose two opt-in, default-off knobs: priority fees (compute-budget pair, 900k CU limit, sized to cover klend's worst-case obligation rather than the golden tx alone — a limit pinned to the 6-reserve golden's 261,070 CU sat below the min(ix_count x 200_000, 1_400_000) budget the same tx receives with no compute-budget ix at all, so enabling the fee could fail a many-reserve rescue that succeeded with it off) and durable nonce (advance ix pinned at index 0, stored nonce as the message blockhash, fail-closed 80-byte parse with an authority-must-equal-wallet gate). With both off, output is byte-identical to the captured goldens.

Motivation: in one 48-hour stretch of February 2026, Kamino processed 55,649 liquidations seizing $19.36M from 30,030 wallets while their owners slept. This is the agent that doesn't sleep.

Custody model

The plugin cannot sign and cannot broadcast. No key material ever enters it; sendTransaction appears nowhere in src/ (grep-verifiable). Every built transaction ships with one zeroed signature slot and is inspected and signed only in the user's own wallet.

Transactions are inbound-only: repay and deposit both move funds from the user's wallet into the user's own position. withdraw_obligation_collateral / borrow_obligation_liquidity / liquidate_obligation appear nowhere in src/ (also grep-verifiable) — those shapes are not gated at runtime, they are structurally unencodable.

Threat model

  • Closed endpoint set. api.kamino.finance plus the configured https-only rpc_url; closed read-only RPC method set (getGenesisHash, getLatestBlockhash, getTokenAccountBalance, getAccountInfo). No simulateTransaction, no sendTransaction.
  • Cluster proof before any transaction is built. Every address encoded into a plan comes from api.kamino.finance, which serves mainnet and only mainnet, so a non-mainnet rpc_url is a misconfiguration rather than a use case. Both tx paths route through one resolve_blockhash, which issues getGenesisHash and refuses unless it matches the pinned mainnet-beta genesis — before a blockhash or nonce is ever fetched. An erroring or unreadable answer is a hard refusal, never a degrade to "assume mainnet".
  • overflow-checks = true in the release profile. Wrapping money arithmetic traps instead of silently producing a wrong amount. Costs ~19.8 KB of wasm; worth it.
  • Fail-closed config. Unknown or misspelled keys are hard errors; args can never carry an rpc_url; deposit and rescue are each disabled until their cap key is explicitly configured.
  • Payload strings are data, never instructions. Ships a prompt-injection test suite with hostile fixture payloads, covering the deposit path too — the README transcript is executable, not prose. The two payload strings that reach model-visible output (a reserve symbol and a price name) are allowlisted to ASCII alphanumerics and length-capped at the parse boundary, so newline/bidi/zero-width characters cannot forge a report line — char::is_control alone misses U+200B, U+202E, U+2028 and U+FEFF.
  • The parse boundary is where it fails closed. Every identifier handed downstream must be a base58 32-byte pubkey (it ends up in a transaction, a URL, or an error message, and base58 carries no newline, quote or /?&#). Every numeric field must be finite and non-negative: Rust's f64::from_str accepts "NaN" and "-1e400", and a negative borrow total drives the buffer above every threshold — reporting a maximally unhealthy position as OK. One unmappable row fails the whole list rather than being dropped, because every row of /users/{wallet}/obligations is one of the user's own positions and dropping one turns "multiple obligations found; specify obligation" into a confident verdict about a different position.
  • The wallet-to-position binding is local. /obligations is already wallet-scoped, so the state.owner check only fires when the response disagrees with the request — but every transaction spends this wallet's tokens into that obligation, so ownership is verified in this source rather than trusted from the API.
  • Zero liquidatable deposit against outstanding debt is CRITICAL, not OK. That is what an obligation looks like after governance drops a collateral asset's liquidation threshold to zero, and it is reachable from honest API data.
  • run never panics, including on a hostile Date header. With overflow-checks = true an overflow is an unrecoverable wasm trap, not an error, so every numeric field of the HTTP Date header and every payload timestamp is range-checked before it reaches a multiplication.
  • No ambient capability. No getrandom in the wasm32-wasip2 tree; no clock in the WIT world (the HTTP Date header is the time source); hand-rolled legacy-tx serialization rather than a signing SDK.
  • Log discipline. Structured log-record emission on every execute (start/complete/fail) — action name only, never args, wallet addresses, or payload content. Nothing on stdout.
  • Snapshot integrity. Snapshots are obligation-bound; a foreign or old-format snapshot degrades to "no prior snapshot", never a spurious alert.

Hard requirements checklist

  • ✅ Layout matches plugins/redact-text: pure core, thin #[cfg(target_family = "wasm")] shim, crate-type = ["cdylib", "rlib"]
  • 150 host tests plus 8 ignored-by-default live-evidence tests — plain cargo test --locked, no wasm toolchain needed
  • cargo build --locked --target wasm32-wasip2 --release clean — 564,783-byte component, exporting exactly zeroclaw:plugin/plugin-info@0.1.0 + zeroclaw:plugin/tool@0.1.0 against the vendored wit/v0 (verified with wasm-tools component wit)
  • cargo clippy -D warnings clean on native and wasm32-wasip2; cargo fmt --check clean
  • ✅ Structured logging via log-record; nothing on stdout
  • ✅ Manifest: capabilities = ["tool"], permissions only config_read + http_client
  • ✅ README carries custody tier, threat model, worked demo transcript, evidence table, and an executable prompt-injection transcript
  • python3 tools/build-registry.py --source-plugins plugins --check-metadata registry.json green (pending unpublished source: liquidation-guard@0.2.0); registry.json deliberately untouched, since the publish workflow owns it
  • ✅ Dual-licensed MIT OR Apache-2.0 (LICENSE + Cargo.toml license field)

Evidence

  • Golden tests: byte-for-byte reproduction of two captured mainnet transactions — repay 3oVjuGzMdAqqJy5poCzHUXqguwypgoM33JfZHFGkb5zb7gfPRtHXgFsgEG7iZP8WWerHttjYdnA8jamwgLbdDiac and deposit 5wcNDh7HcUVEipGHk2xnzMigX1LwkPBPvsMJPvukUU3mxGkFTe1WYY3PMdHnufwCHkeDnUa1gECsYccEDuUDF7np.
  • All fixtures were captured from mainnet on 2026-07-18/19 (obligations, reserves, prices, golden txs); the README demo transcript is generated from them — no invented numbers.
  • Live evidence runs, 2026-07-19 (details and exact errors in the README evidence table): fresh api.kamino.finance payloads parse cleanly through the real parsers, and four tx shapes were simulated via simulateTransaction (sigVerify:false, curl, outside the plugin) against public mainnet RPC. Base rescue and fee-on rescue both execute through every refresh on real klend state and fail only at the terminal token transfer with Custom(1) insufficient funds — expected for an unsigned, unfunded tx; the compute-budget ixs succeed, 151,025 CU consumed (that run predates the raise of the CU ceiling to 900k, and sits far under either). The deposit tx does the same on its path. The nonce-composed tx is sanitized by a real node, which executes advance_nonce_account at index 0 against a real mainnet nonce account and demands the stored authority's signature — the exact condition the plugin's own parser refuses fail-closed up front, also demonstrated live against that same account through guard::run.
  • Full upstream CI pipeline (registry contract, WIT drift, component validation, package dry-run) reproduced green locally at the submitted commit.

Rollback

Delete plugins/liquidation-guard/. That is the whole change: 32 files, all
under that one directory, +11,604 / -0. No host changes, no wit/ changes,
no edits to tools/ or the workflows, and registry.json is untouched (the
publish workflow generates it). Nothing else in the repo references the
directory.

Limits (stated, not hidden)

Referrer-bearing obligations are refused outright. SOL-level LST quoting applies to the pinned mint set only (JitoSOL, mSOL, bSOL, jupSOL, INF, bnSOL); any other LST keeps a token-level quote rather than a guessed stake rate. Withdraw and borrow shapes are deliberately unencodable. Collateral cToken mints are assumed classic SPL Token (Kamino cTokens are never Token-2022 — documented assumption). Position data comes from Kamino's own price and loan endpoints; the plugin does not independently reconstruct obligation state from getProgramAccounts.

Questions for maintainers

  • Scope of one plugin. This is one component with four actions rather than four small ones, because check/portfolio/rescue/deposit all read the same obligation, price, and reserve state and share the cap-and-label discipline. Happy to split if you would rather review the read-only path separately from the two transaction builders.
  • Registry timing. registry.json is deliberately untouched here. If you would prefer the entry staged in the PR instead of generated at publish time, say the word.
  • Pinned LST set. The SOL-level quoting table is pinned by mint (src/guard.rs::PINNED_LST_MINTS) rather than matched on payload symbols, on purpose. Adding a mint is a one-line change plus a fixture; tell me if you want more of them covered before merge.

@Ansh-699
Ansh-699 force-pushed the submission/liquidation-guard branch 5 times, most recently from 93ac56e to 813ba6d Compare July 26, 2026 14:24
…transactions

One tool, `kamino_guard`, four actions:

- `check` — tiered health warning (OK/WATCH/WARN/CRITICAL), liquidation-price
  forecast in both directions (each in its own asset's oracle price; LST
  collateral's drop line quoted at the underlying SOL level via stake rate),
  interest-drift attribution, and ranked remedies restoring to the WATCH
  boundary.
- `portfolio` — the same across every obligation of a wallet.
- `rescue` — base64 unsigned `repay_obligation_liquidity_v2` transaction.
- `deposit` — base64 unsigned
  `deposit_reserve_liquidity_and_obligation_collateral_v2` transaction.

Both builders compose two opt-in, default-off knobs: priority fees
(compute-budget pair) and durable nonce (advance ix pinned at index 0,
fail-closed 80-byte parse with an authority-must-equal-wallet gate). With both
off, output is byte-identical to the captured mainnet goldens.

Custody: the plugin cannot sign and cannot broadcast. No key material, one
zeroed signature slot, no `sendTransaction` anywhere in `src/`. Transactions
are inbound-only — repay and deposit both move funds from the user's wallet
into the user's own position; withdraw/borrow/liquidate appear nowhere in
`src/`. Both bans are grep-verifiable. The wallet-to-position binding is
checked locally against `state.owner` rather than trusted from the API, so the
inbound-only property is verifiable in this source.

Everything the plugin parses comes from a remote HTTP API or a language model,
so the parse boundary is where it fails closed: identifier fields must be
base58 32-byte pubkeys, numeric fields must be finite and non-negative (Rust's
`f64::from_str` accepts "NaN" and "-1e400", and a negative total would report a
maximally unhealthy position as OK), display strings are allowlisted and
length-capped before they reach model-visible output, and one unmappable row
fails the whole list rather than silently removing one of the user's positions
from consideration. Every numeric field of the HTTP `Date` header and every
payload timestamp is range-checked, because with `overflow-checks = true` an
overflow is an unrecoverable wasm trap rather than an error.

Evidence: byte-for-byte reproduction of two captured mainnet transactions
(repay 3oVjuGzMdAqqJy5poCzHUXqguwypgoM33JfZHFGkb5zb7gfPRtHXgFsgEG7iZP8WWerHttjYdnA8jamwgLbdDiac,
deposit 5wcNDh7HcUVEipGHk2xnzMigX1LwkPBPvsMJPvukUU3mxGkFTe1WYY3PMdHnufwCHkeDnUa1gECsYccEDuUDF7np);
150 tests plus 8 ignored-by-default live-evidence tests; clippy -D warnings on
native and wasm32-wasip2; four transaction shapes simulated against mainnet
via simulateTransaction (curl, outside the plugin). Release artifact is
564,783 bytes for wasm32-wasip2.

Dual-licensed MIT OR Apache-2.0.
Two defects the audit surfaced, both small and both in the class the
release gate already caught once.

The citation loop: rescue.rs pointed at the README's "Design decisions"
section for the pinned classic-SPL token program, but that section covers
LST pricing, close factor and grace period — never the token program.
The README's own Token-2022 bullet then pointed at a "Deviations" section
that does not exist anywhere in the file, so a reviewer following either
trail ran out of road. rescue.rs now cites Future work, which does document
the assumption, and the README bullet states the upgrade path directly
instead of forwarding to nothing.

amt() rendered inf/NaN verbatim while its sibling pct() returned "n/a" for
exactly that case, so a single report could print
"Deposit inf SOL -> LTV 59.9%, buffer 25.0%" — a guarded percentage beside
an unguarded amount. remedy::rank sizes every remedy by dividing a USD
delta by an oracle price, so the unbounded case is reachable from real
inputs rather than theoretical. Guarded to match pct, with a regression
test over INFINITY/NEG_INFINITY/NAN that fails without it.

Release wasm moves 564,783 -> 564,935 bytes; the evidence table's byte
count and its pre-audit delta are updated to the rebuilt artifact.
…ing record, and fix the install path

The wasip2 section claimed getrandom "ruled out solana-sdk and any crate that
pulls it in transitively". That was stale. Re-measured per crate on Rust
1.96.1: solana-hash/pubkey/instruction/message and solana-sdk all compile for
wasm32-wasip2, but every one above solana-hash puts getrandom back in the tree
(safety invariant 7), and solana-transaction -- the crate that would actually
serialize a transaction -- fails to compile outright, because it gates a
wasm-bindgen browser module on target_arch = "wasm32" and wasip2 matches it.
That is why the legacy serializer, shortvec encoder and base64 codec are
hand-rolled; the README now shows the measurement rather than asserting the
conclusion.

Also:
- Replace the documented `zeroclaw tool call kamino_guard` invocation, which
  is not a subcommand that exists, with the real deployment: `zeroclaw plugin
  install` plus a `zeroclaw cron add ... --agent ... --prompt` agent schedule.
- Add an operating record: 190 successful `check` completions inside a real
  zeroclaw daemon between 2026-07-24 and 2026-08-03, counted from the
  plugin's own PluginOutcome::Success emissions, with the operating-day gaps
  stated rather than smoothed over. Drops the now-false "no host build was
  run" line.
- Add a section on what a read-only monitor structurally cannot do: a remedy
  amount rather than a risk score, the transaction itself, landing during the
  congestion that caused the alert, and why none of that costs custody tier.
@Ansh-699
Ansh-699 marked this pull request as draft August 3, 2026 12:00
@Ansh-699

Ansh-699 commented Aug 3, 2026

Copy link
Copy Markdown
Author

Converting this to a draft.

This branch was opened on 20 July, before the bounty listing was updated with the guidance that registry PRs should not be opened during the bounty and that registry merges happen separately after judging. Parking it as a draft so it stays out of the review queue, rather than closing it — happy to mark it ready whenever that suits the maintainers.

The bounty submission itself is a showcase post, not this PR; this is just where the code lives upstream.

Latest push (35681aa) is documentation only, and corrects one thing worth flagging on its own since the listing states the opposite. The listing says, verified by build, that the modular solana-* crates compile clean to wasm32-wasip2 with "no hand-rolled byte encoding needed to build and serialize transactions". Re-measured per crate on Rust 1.96.1:

crate wasm32-wasip2 getrandom in tree
solana-hash compiles none
solana-pubkey compiles v0.2.17
solana-instruction compiles v0.2.17
solana-message compiles v0.2.17
solana-sdk compiles v0.1.16 + v0.2.17
solana-transaction fails to compile

solana-transaction v2.2.3 gates a wasm-bindgen browser module on #[cfg(target_arch = "wasm32")] (src/lib.rs:113, :213). wasm32-wasip2 is target_arch = "wasm32", so that JS-glue path compiles for a non-browser target and calls message_data / partial_sign, which are not present:

error[E0599]: no method named `message_data` found for reference `&Transaction`
error[E0599]: no method named `partial_sign` found for mutable reference `&mut Transaction`

default-features = false does not avoid it. Reproduce with cargo add solana-transaction@2 && cargo build --target wasm32-wasip2.

So the compile wall is real, but only on the transaction-serialization layer — which is the layer the listing says is covered. That is why this plugin hand-rolls the legacy serializer, shortvec encoder and base64 codec. My previous claim that getrandom "ruled out solana-sdk" was also wrong and is corrected in the same push: solana-sdk compiles, it just brings getrandom back into the tree, which this plugin declines by policy (safety invariant 7) rather than by necessity.

Also in scope for #147: this plugin requests config_read and does not yet declare config_schema. Aware of it, will migrate.

The previous wording said solana-transaction 'fails to compile', which is only
true on default features. features = ["bincode"] (or blake3, which implies it)
does compile -- a reader could have disproved the claim in one command.

The finding is sharper stated precisely: the wasm-bindgen browser module is
gated on target_arch = "wasm32" alone, so it is compiled for wasip2, where it
calls two methods only the bincode feature provides. Enabling that feature
builds but is still wrong here -- it pulls getrandom back in and emits a
JS-binding shim into a component with no JavaScript host. Browser wasm is
target_os = "unknown" and both WASI targets are target_os = "wasi", so the
correct upstream gate is all(target_arch = "wasm32", target_os = "unknown").
The operating-record table was written from a partial read of the daemon log
and had gone stale. Recounted from the full log at
2026-08-04T13:50:11Z:

- successful check completions: 190 -> 242 (of 243 started)
- operating days: seven -> eight (Jul 24-27, Aug 1-4)

Adds what the earlier table omitted and what actually demonstrates the
component works unattended: the tier split (141 WATCH -> 0 alerts, 101 WARN
-> 101 alerts, no miss in either direction), delivery latency measured from
the host's own tool_call_result records, the measured 20-minute cadence, the
4 d 16 h outage, and the six failure outcomes -- including the two runs lost
to a deleted daemon binary. Every figure has the grep that checks it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant